winbrew_windows\deployment\msi/
path.rs

1//! Pure MSI path and reference normalization helpers.
2//!
3//! The functions in this module do not touch the filesystem or the MSI API.
4//! They only normalize strings, choose between MSI long/short names, and map
5//! symbolic references to concrete `PathBuf` values when the information is
6//! already present in the derived lookup tables.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11pub(super) fn normalize_path(path: &Path) -> String {
12    // Normalize a filesystem path for storage and comparison.
13    //
14    // The normalization strips Windows verbatim prefixes, converts path
15    // separators to forward slashes, and lowercases the result so the storage
16    // layer can compare paths consistently.
17    let raw = path.to_string_lossy();
18    let stripped = raw
19        .strip_prefix(r"\\?\UNC\")
20        .map(|value| format!(r"\\{}", value))
21        .or_else(|| raw.strip_prefix(r"\\?\").map(ToOwned::to_owned))
22        .unwrap_or_else(|| raw.to_string());
23
24    stripped.replace('\\', "/").to_ascii_lowercase()
25}
26
27pub(super) fn normalize_registry_key_path(path: &str) -> String {
28    // Normalize a registry key path for stable lookups.
29    path.trim().to_ascii_lowercase()
30}
31
32pub(super) fn select_msi_name(value: &str) -> Option<String> {
33    // Select the best display name from an MSI `long|short` encoded field.
34    //
35    // MSI tables often store both forms in one column. The scanner prefers the
36    // long name, falls back to the short name only when needed, and treats `.`
37    // as an explicit "no value" marker.
38    let value = value.trim();
39    if value.is_empty() || value == "." {
40        return None;
41    }
42
43    let selected = match value.split_once('|') {
44        Some((short_name, long_name)) => {
45            let long_name = long_name.trim();
46            let short_name = short_name.trim();
47
48            if !long_name.is_empty() && long_name != "." {
49                long_name
50            } else if !short_name.is_empty() && short_name != "." {
51                short_name
52            } else {
53                return None;
54            }
55        }
56        None => value,
57    };
58
59    if selected.is_empty() || selected == "." {
60        None
61    } else {
62        Some(selected.to_string())
63    }
64}
65
66pub(super) fn resolve_reference_path(
67    reference: &str,
68    directory_paths: &HashMap<String, PathBuf>,
69    file_paths: &HashMap<String, PathBuf>,
70) -> Option<PathBuf> {
71    // Resolve an MSI-style path reference into a concrete path when possible.
72    //
73    // Supported forms include `[#FileKey]`, `[DirectoryId]suffix`, direct row
74    // keys, and already-literal filesystem paths. Unknown references are left
75    // unresolved so the caller can keep the output conservative.
76    let reference = reference.trim();
77    if reference.is_empty() {
78        return None;
79    }
80
81    if let Some(key) = reference
82        .strip_prefix("[#")
83        .and_then(|value| value.strip_suffix(']'))
84    {
85        return file_paths
86            .get(key)
87            .cloned()
88            .or_else(|| directory_paths.get(key).cloned());
89    }
90
91    if let Some(rest) = reference.strip_prefix('[')
92        && let Some((key, suffix)) = rest.split_once(']')
93    {
94        let base = file_paths
95            .get(key)
96            .cloned()
97            .or_else(|| directory_paths.get(key).cloned())?;
98        let suffix = suffix.trim_start_matches(['\\', '/']);
99
100        return Some(if suffix.is_empty() {
101            base
102        } else {
103            base.join(suffix)
104        });
105    }
106
107    if let Some(path) = file_paths.get(reference) {
108        return Some(path.clone());
109    }
110
111    if let Some(path) = directory_paths.get(reference) {
112        return Some(path.clone());
113    }
114
115    if reference.contains('\\') || reference.contains('/') || reference.contains(':') {
116        return Some(PathBuf::from(reference));
117    }
118
119    None
120}
121
122#[cfg(test)]
123mod tests {
124    use super::{normalize_path, normalize_registry_key_path, select_msi_name};
125    use std::path::Path;
126
127    #[test]
128    fn select_msi_name_prefers_long_name() {
129        assert_eq!(
130            select_msi_name("SHORT|Long Name"),
131            Some("Long Name".to_string())
132        );
133    }
134
135    #[test]
136    fn select_msi_name_handles_plain_values() {
137        assert_eq!(
138            select_msi_name("FolderName"),
139            Some("FolderName".to_string())
140        );
141        assert_eq!(select_msi_name("SHORTNAM|."), Some("SHORTNAM".to_string()));
142        assert_eq!(select_msi_name("."), None);
143        assert_eq!(select_msi_name(""), None);
144    }
145
146    #[test]
147    fn normalize_path_lowercases_and_uses_forward_slashes() {
148        assert_eq!(
149            normalize_path(Path::new(r"C:\Tools\Demo\bin\App.EXE")),
150            "c:/tools/demo/bin/app.exe"
151        );
152    }
153
154    #[test]
155    fn normalize_registry_key_path_lowercases() {
156        assert_eq!(
157            normalize_registry_key_path(r"Software\Demo\Config"),
158            "software\\demo\\config"
159        );
160    }
161}